Skip to content

Improve CQL Injection Query #200

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 49 commits into
base: main
Choose a base branch
from

Conversation

jeongsoolee09
Copy link
Contributor

@jeongsoolee09 jeongsoolee09 commented Jun 19, 2025

What this PR contributes

  • Improve CQL Injection Query.

    • A new class CqlQueryRunnerCall now expands and replaces TaintedCqlClause, now covering cds.run, cds.db.run, srv.run, and tx.run.
      • Also, it models CRUD-style "shortcut calls" that translates to running a CQL on a service under the hood.
    • Expand base objects as possible receivers of methods .run and property read entities:
      • EntityEntry is "absorbed" into EntityReference. Plus, EntityReference now covers more examples, namely, cds as a shortcut to cds.db.
    • Make the alert message more useful by making the query itself the primary location which is different from sink (the string concatenation).
  • Add robust test cases (This is the gist of this PR, please take a look for the behavioral summary description of what this PR aims to implement).

    • send11-send17: Service1 runs query on the database service using cds.run and friends.
    • send21-send25: Service1 runs query on itself by await-ing the query.
    • send31-send37: Service1 runs query on itself using this.run and friends.
    • send41-send47: Service1 runs query on Service2 using Service2.run and friends.
    • send5: Service1 runs query on Service2 using CQN parsed with cds.ql.
    • send6: Service1 runs query on the database service using CQN parsed with cds.parse.cql.
    • send7: Service1 runs query on the database service using CQN parsed with global function CQL.
    • send81: Service1 runs query on Service2 using an unparsed CDL string (only valid in old versions of CAP).
    • send91-send97: Service1 runs query on Service2 using Service2.tx( tx => tx.run(...) ) and friends.
    • send101-send107: Service1 runs query on itself using this.tx( tx => tx.run(...) ) and friends.
    • send111-send117: Service1 runs query on the database service using cds.tx( tx => tx.run(...) ) and friends.
    • send121-send127: Service1 runs query on the database service using cds.db.tx( tx => tx.run(...) ) and friends.

Future works

  • The alert message can be more refined: if a sink is in cds.read(...).from(...), only the cds.read(...) part is alerted on, where the entire chained method call is more desirable as a alert location.
  • SensitiveExposure.ql seems to be quite brittle, it needs a rewrite. This PR only updates the query's reference to old definitions that are no longer available.

@jeongsoolee09 jeongsoolee09 marked this pull request as draft June 19, 2025 13:22
Copy link

@github-advanced-security github-advanced-security bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@jeongsoolee09 jeongsoolee09 marked this pull request as ready for review June 24, 2025 23:08
@jeongsoolee09 jeongsoolee09 requested a review from lcartey June 24, 2025 23:08
@jeongsoolee09
Copy link
Contributor Author

Added more test cases by moving over the test cases in the old cqlinjection.js (which was once moved to old/cqlinjection.js) and further split the test cases:

  • concatenation between a string literal and an expression: SELECT.fromEntity1.where("ID=" + id);
  • concatenation between a template literal and an expression: SELECT.fromEntity1.where(ID= + id);
  • concatenation between a template element and an expression: SELECT.fromEntity1.where(ID=${id});
  • Tagged template literal with an expression interpolated (NOTE: FP): SELECT.fromEntity1.whereID=${id};

Copy link
Contributor

@lcartey lcartey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've completed a partial review, with some initial comments.

This predicate is (1) not used anywhere, and (2)
nothing but a simple alias to
`API::Node.asSource/0`.
The original only covered `StringLiterals`, but
`getStringValue` covers more cases
("constant folding").
…FromCdsLib/0`

As stated in the new docstring of `CdsFacade`, the
same `cds_facade` object can be accessed either via
`@sap/cds` or `@sap/cds/lib`. Therefore, there is
no distinction to be made between the two.

Also, the `CdsDb` and the `GloballyAccessedCdsDbService`
aimed to capture the same thing, so delete the latter
in favor of the former (it's clearer and more concise).
…g to it

This avoids duplicating the sink definition
in `getQueryOfSink` and also in the `isSink` of
the configuration.
@jeongsoolee09 jeongsoolee09 requested a review from lcartey June 30, 2025 13:50
Now, "@sap/cds/lib" is also a valid import path
of a `CdsFacade` object.
* This step jumps from `id` in the property value expression to the enclosing object `{ id: "" + id }`.
* This in conjunction with the above step 2 will make the taint tracker jump from `id` to the entire
* INSERT clause.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why we need this case? I would have expected an untrusted property value for id could not cause an injection vulnerability in this query.

As a real world example, adding this step introduces a new result in cloud-cap-samples:
https://github.com/SAP-samples/cloud-cap-samples/blob/main/reviews/srv/reviews-service.js#L21-L25
That looks to me like a false positive, but perhaps I'm missing something!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, this step should only consider argument->returnvalue progagations where the argument is a string concatenation or originates from one.

I've addressed it in 689e00a. Now it no longer creates the FP alert on the reviews-service.js.


ServiceInstance getRunner() { result = srv }

SourceNode getContextObject() {
result = this.getAnArgument().getALocalSource() and not result instanceof FunctionNode
/* 1. An object node passed as the first argument to a call to `srv.tx`. */
result = txCall.getAnArgument().getALocalSource() and not result instanceof FunctionNode
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says first argument, did you mean getArgument(0)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The srv.tx method does not accept multiple context objects at once, and the callback function always comes after a context object argument, so technically this does what we want. However, it's still more accurate to just put down exactly what we want. 👍

* concatenation expression. e.g.
* ``` javascript
* cds.read("Entity1").where(`ID=${id}`); // Notice the surrounding parentheses!
* cds.create("Entity1").entries({id: "" + id});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this case is covered by this class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very glad you pointed this out, create is indeed a hole in our coverage that needs to be modeled.

* A string concatenation expression included in a CQL shortcut method call. e.g.
* ``` javascript
* cds.read("Entity1").where(`ID=${id}`); // Notice the surrounding parentheses!
* cds.create("Entity1").entries({id: "" + id});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the entries cases in this qldoc are actually covered by the class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is related to our hole in the coverage for create, thus will be addressed together.


this.on("send00144", async (req) => {
const { id, amount } = req.data;
cds.update("Entity1").set("col1 = col1" + amount).where`col1 = ${id}`; // FP
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this considered an FP? Also, is set permitted to have a string argument? The documentation says:

Specifies the data to update...

  1. As a single-expression tagged template string
  2. As an object with keys being element names of the target entity and values being simple values, query-by-example expressions, or CQN expressions:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ouch, that's labeled as FP by mistake. The where clause is a tagged literal expression .where`col1 = ${id}` which if it were only by itself would have been an FP (SAFE) case. But the .set("col = col1" + amount) sitting there makes it a TP (UNSAFE) case.


this.on("send00174", async (req) => {
const { id } = req.data;
cds.delete("Entity1").where`ID = ${id}`; // FP
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still an FP?

Copy link
Contributor Author

@jeongsoolee09 jeongsoolee09 Jul 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit misleading to mark a case as "FP" when what I really mean is this is a COMPLIANT case and thus not something our query should not alert on.

Maybe I should use "SAFE" / "UNSAFE" instead of "FP" / (no label). I'll address them in a commit.

this.on("send00214", async (req) => {
const { id } = req.data;
const { Service1Entity } = this.entities;
await SELECT.from(Service1Entity).where`ID=${id}`; // FP
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still an FP?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is an FP (it's safe). Same strategy as this comment will be used and will be addressed together.

this.on("send00224", async (req) => {
const { id } = req.data;
const { Service1Entity } = this.entities;
await INSERT.into(Service1Entity).entries`ID = ${id}`; // FP
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a number of other FP markers in this file that should be reviewed - I haven't higlighted them all individually.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is an FP (it's safe). Same strategy as this comment will be used and will be addressed together.

CallNode txCall;

CdsTransaction() {
txCall = srv.getAMemberCall("tx") and
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This charpred either selects the tx call or the callback passed to the tx call - can you provide a quick example in the doc between the two? It's been very helpful where you've done that else where in the code.

or
/* Imported from `cds.ql` */
exists(CdsFacade cds |
cds.getMember("ql").getMember(name).getAValueReachableFromSource().asExpr() = this
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have to be a VarRef?

/* Imported from `cds.ql` */
exists(CdsFacade cds |
cds.getMember("ql").getMember(name).getAValueReachableFromSource().asExpr() = this
exists(VarRef varRef | this = varRef |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exists is currently superfluous. Did you mean to do something with different between varRef and this?

jeongsoolee09 and others added 3 commits July 1, 2025 14:17
1. The second step should jump from a argument
of a CQN object *only if the argument originates
from a string concatentation*.

Note that this new version identifies the end point
using a successive application of `getAPredecessor`;
it overapproximates and might accidentally include
code that's not necessarily what we want.

2. The third is a specialization of the second step,
and concerns itself only to the property writes to
the object to be passed as an argument to the CQN
query builder for INSERT and UPSERT.
Co-authored-by: Luke Cartey <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants